Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 9ea4efe98ab64cc1294fae7fd9909e6edc4e4c1f


Parents : c604e99
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-26T05:44:02-05:00

feat: fix Android support with Codec2 fallback, improve RNode flasher UI, and refine WebSocket connection handling

Changes
Diff

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5ee778c7..fecb183f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,9 +6,10 @@ All notable changes to this project will be documented in this file.
### Fixed
-- **Android LXST / Codec2**: Preload libcodec2 after the Chaquopy runtime starts, reload LXST Codec2 bindings when a soft import left Codec2 unset, and probe pycodec2 before mesh imports so Codec2 voice profiles work instead of reporting no codec on device.
+- **Android LXST / Codec2**: When the Chaquopy `pycodec2.so` extension is an empty stub, fall back to a ctypes Codec2 binding over the bundled `libcodec2.so` so LXST Codec2 voice profiles work on device. Still preload jniLibs Codec2 and reload soft-imported LXST bindings after probe.
+- **Android RNode flasher**: Open native flasher returns a real status, keeps USB-serial classes through R8, uses an ActionBar theme, and surfaces startup failures instead of silently doing nothing. Bluetooth Open settings tries GrapheneOS-friendly fallbacks (app details, Bluetooth settings, general Settings) instead of toasting unavailable.
+- **Connection banners**: Do not flash disconnected on startup before the first successful WebSocket open. Debounce disconnect UI for 2.5s and only show reconnected when the disconnect banner was actually shown. Foreground recovery prefers a ping for longer before forcing a reconnect.
- **Android calls**: Clarify that the web audio bridge on Android uses native mic and speaker through the telephone audio bridge, not browser getUserMedia.
-- **Android RNode flasher**: Bluetooth Allow and Open settings open the system permission UI when runtime permission is missing or permanently denied. The capabilities banner shows Allow Bluetooth only when needed and otherwise steers users to the native flasher for USB.
- **Browser calls (Docker / HTTPS)**: Refresh Devices calls getUserMedia first so Brave and Chromium show the microphone permission prompt instead of failing early when enumerateDevices lists no inputs before permission is granted.
- **HTTP security headers**: Send Permissions-Policy allowing microphone and camera for this origin so reverse proxies that omit the header do not block capture by default.
- **UI language**: Persist language changes over the config HTTP API (not WebSocket-only), normalize legacy locale codes, and stop the Reticulum manual language picker from overwriting app UI language.

diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
index 6f031456..c5bf3735 100644
--- a/android/app/proguard-rules.pro
+++ b/android/app/proguard-rules.pro
@@ -1,5 +1,9 @@
-keep class com.chaquo.python.** { *; }
-keep class com.meshchatx.** { *; }
+-keepclassmembers class com.meshchatx.MainActivity$MeshChatXAndroidBridge {
+ @android.webkit.JavascriptInterface <methods>;
+}
+-keep class com.hoho.android.usbserial.** { *; }
-keep class org.json.** { *; }
-keep class org.conscrypt.** { *; }
-dontwarn com.chaquo.python.**

diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index de844535..976b62a9 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -95,6 +95,7 @@
android:exported="false"
android:label="@string/rnode_flasher_title"
android:parentActivityName=".MainActivity"
+ android:theme="@style/Theme.MeshChatX.Flasher"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|density|fontScale|keyboard|keyboardHidden|navigation|uiMode|colorMode|layoutDirection" />
<!--

diff --git a/android/app/src/main/java/com/meshchatx/AppSettingsLauncher.java b/android/app/src/main/java/com/meshchatx/AppSettingsLauncher.java
new file mode 100644
index 00000000..378919a5
--- /dev/null
+++ b/android/app/src/main/java/com/meshchatx/AppSettingsLauncher.java
@@ -0,0 +1,103 @@
+package com.meshchatx;
+
+import android.content.ActivityNotFoundException;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.provider.Settings;
+import android.util.Log;
+import android.widget.Toast;
+
+/**
+ * Open app / Bluetooth settings with GrapheneOS-friendly fallbacks.
+ *
+ * ACTION_APPLICATION_DETAILS_SETTINGS alone can fail on hardened builds. Try
+ * several intents before surfacing an error to the user.
+ */
+public final class AppSettingsLauncher {
+ private static final String TAG = "AppSettingsLauncher";
+
+ private AppSettingsLauncher() {
+ }
+
+ public static boolean openAppDetails(Context context) {
+ if (context == null) {
+ return false;
+ }
+ String packageName = context.getPackageName();
+ Intent[] candidates =
+ new Intent[] {
+ detailsIntent(packageName),
+ detailsIntentWithSettingsPackage(packageName),
+ new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
+ .setData(Uri.parse("package:" + packageName)),
+ new Intent(Settings.ACTION_MANAGE_APPLICATIONS_SETTINGS),
+ new Intent(Settings.ACTION_SETTINGS),
+ };
+ return startFirstAvailable(context, candidates, "Could not open app settings");
+ }
+
+ public static boolean openBluetoothSettings(Context context) {
+ if (context == null) {
+ return false;
+ }
+ String packageName = context.getPackageName();
+ Intent[] candidates =
+ new Intent[] {
+ new Intent(Settings.ACTION_BLUETOOTH_SETTINGS),
+ detailsIntent(packageName),
+ detailsIntentWithSettingsPackage(packageName),
+ new Intent(Settings.ACTION_MANAGE_APPLICATIONS_SETTINGS),
+ new Intent(Settings.ACTION_SETTINGS),
+ };
+ return startFirstAvailable(
+ context,
+ candidates,
+ "Could not open Bluetooth or app settings"
+ );
+ }
+
+ private static Intent detailsIntent(String packageName) {
+ Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
+ intent.setData(Uri.fromParts("package", packageName, null));
+ return intent;
+ }
+
+ private static Intent detailsIntentWithSettingsPackage(String packageName) {
+ Intent intent = detailsIntent(packageName);
+ intent.setPackage("com.android.settings");
+ return intent;
+ }
+
+ private static boolean startFirstAvailable(
+ Context context,
+ Intent[] candidates,
+ String failureMessage
+ ) {
+ Exception last = null;
+ for (Intent base : candidates) {
+ if (base == null) {
+ continue;
+ }
+ try {
+ Intent intent = new Intent(base);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ context.startActivity(intent);
+ return true;
+ } catch (ActivityNotFoundException | SecurityException e) {
+ last = e;
+ Log.w(TAG, "Settings intent failed: " + base.getAction() + " " + e.getMessage());
+ } catch (Exception e) {
+ last = e;
+ Log.w(TAG, "Settings intent failed: " + base.getAction() + " " + e.getMessage());
+ }
+ }
+ String detail = last != null && last.getMessage() != null ? last.getMessage() : "";
+ Toast.makeText(
+ context,
+ detail.isEmpty() ? failureMessage : failureMessage + ": " + detail,
+ Toast.LENGTH_LONG)
+ .show();
+ return false;
+ }
+}

diff --git a/android/app/src/main/java/com/meshchatx/MainActivity.java b/android/app/src/main/java/com/meshchatx/MainActivity.java
index 04e4b024..469e21f9 100644
--- a/android/app/src/main/java/com/meshchatx/MainActivity.java
+++ b/android/app/src/main/java/com/meshchatx/MainActivity.java
@@ -646,17 +646,11 @@ public class MainActivity extends AppCompatActivity {
}
void openAppPermissionSettings() {
- try {
- Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
- intent.setData(Uri.fromParts("package", getPackageName(), null));
- startActivity(intent);
- } catch (ActivityNotFoundException ignored) {
- try {
- startActivity(new Intent(Settings.ACTION_MANAGE_APPLICATIONS_SETTINGS));
- } catch (ActivityNotFoundException ignoredAgain) {
- Toast.makeText(this, "App settings unavailable", Toast.LENGTH_SHORT).show();
- }
- }
+ AppSettingsLauncher.openAppDetails(this);
+ }
+
+ boolean openBluetoothPermissionSettings() {
+ return AppSettingsLauncher.openBluetoothSettings(this);
}
private static final String PREF_BT_PERM_PROMPTED_PREFIX = "bt_perm_prompted_";
@@ -804,7 +798,7 @@ public class MainActivity extends AppCompatActivity {
}
final boolean ok = granted;
if (!ok && isBluetoothPermanentlyDenied()) {
- openAppPermissionSettings();
+ openBluetoothPermissionSettings();
Toast.makeText(
this,
"Bluetooth blocked. Enable it in app settings.",
@@ -1716,7 +1710,7 @@ public class MainActivity extends AppCompatActivity {
}
if (activity.isBluetoothPermanentlyDenied()) {
activity.runOnUiThread(() -> {
- activity.openAppPermissionSettings();
+ activity.openBluetoothPermissionSettings();
Toast.makeText(
activity,
"Bluetooth blocked. Enable it in app settings.",
@@ -1765,19 +1759,7 @@ public class MainActivity extends AppCompatActivity {
@JavascriptInterface
public String requestUsbPermissions() {
- activity.runOnUiThread(() -> {
- try {
- activity.startActivity(
- new Intent(activity, com.meshchatx.rnode.RNodeFlasherActivity.class)
- );
- } catch (Exception e) {
- Toast.makeText(
- activity,
- "Could not open native RNode flasher",
- Toast.LENGTH_SHORT).show();
- }
- });
- return "requested";
+ return openRNodeFlasher();
}
@JavascriptInterface
@@ -1786,29 +1768,83 @@ public class MainActivity extends AppCompatActivity {
}
@JavascriptInterface
- public void openRNodeFlasher() {
- activity.runOnUiThread(() -> {
- try {
- activity.startActivity(
- new Intent(activity, com.meshchatx.rnode.RNodeFlasherActivity.class)
- );
- } catch (Exception e) {
- Toast.makeText(
- activity,
- "Could not open native RNode flasher",
- Toast.LENGTH_SHORT).show();
+ public String openRNodeFlasher() {
+ try {
+ final java.util.concurrent.CountDownLatch latch =
+ new java.util.concurrent.CountDownLatch(1);
+ final String[] result = new String[] {"error:unknown"};
+ activity.runOnUiThread(() -> {
+ try {
+ Intent intent =
+ new Intent(activity, com.meshchatx.rnode.RNodeFlasherActivity.class);
+ intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ activity.startActivity(intent);
+ result[0] = "ok";
+ } catch (Exception e) {
+ String msg =
+ e.getMessage() != null && !e.getMessage().isEmpty()
+ ? e.getMessage()
+ : "Could not open native RNode flasher";
+ result[0] = "error:" + msg;
+ Toast.makeText(activity, msg, Toast.LENGTH_LONG).show();
+ } finally {
+ latch.countDown();
+ }
+ });
+ if (!latch.await(3, java.util.concurrent.TimeUnit.SECONDS)) {
+ return "timeout";
}
- });
+ return result[0];
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return "interrupted";
+ }
}
@JavascriptInterface
- public void openBluetoothSettings() {
- activity.runOnUiThread(activity::openAppPermissionSettings);
+ public String openBluetoothSettings() {
+ try {
+ final java.util.concurrent.CountDownLatch latch =
+ new java.util.concurrent.CountDownLatch(1);
+ final boolean[] launched = new boolean[] {false};
+ activity.runOnUiThread(() -> {
+ try {
+ launched[0] = activity.openBluetoothPermissionSettings();
+ } finally {
+ latch.countDown();
+ }
+ });
+ if (!latch.await(2, java.util.concurrent.TimeUnit.SECONDS)) {
+ return "timeout";
+ }
+ return launched[0] ? "ok" : "unavailable";
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return "interrupted";
+ }
}
@JavascriptInterface
- public void openUsbSettings() {
- activity.runOnUiThread(activity::openAppPermissionSettings);
+ public String openUsbSettings() {
+ try {
+ final java.util.concurrent.CountDownLatch latch =
+ new java.util.concurrent.CountDownLatch(1);
+ final boolean[] launched = new boolean[] {false};
+ activity.runOnUiThread(() -> {
+ try {
+ launched[0] = AppSettingsLauncher.openAppDetails(activity);
+ } finally {
+ latch.countDown();
+ }
+ });
+ if (!latch.await(2, java.util.concurrent.TimeUnit.SECONDS)) {
+ return "timeout";
+ }
+ return launched[0] ? "ok" : "unavailable";
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return "interrupted";
+ }
}
@JavascriptInterface

diff --git a/android/app/src/main/java/com/meshchatx/rnode/RNodeFlasherActivity.java b/android/app/src/main/java/com/meshchatx/rnode/RNodeFlasherActivity.java
index f0cbaa01..0701cc12 100644
--- a/android/app/src/main/java/com/meshchatx/rnode/RNodeFlasherActivity.java
+++ b/android/app/src/main/java/com/meshchatx/rnode/RNodeFlasherActivity.java
@@ -1,7 +1,6 @@
package com.meshchatx.rnode;
import android.Manifest;
-import android.content.ActivityNotFoundException;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
@@ -10,7 +9,6 @@ import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
-import android.provider.Settings;
import android.view.View;
import android.webkit.CookieManager;
import android.widget.AdapterView;
@@ -26,6 +24,7 @@ import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
+import com.meshchatx.AppSettingsLauncher;
import com.meshchatx.LocalhostTrustOkHttpClient;
import com.meshchatx.R;
@@ -82,63 +81,75 @@ public final class RNodeFlasherActivity extends AppCompatActivity implements Usb
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
- setContentView(R.layout.activity_rnode_flasher);
- if (getSupportActionBar() != null) {
- getSupportActionBar().setTitle(R.string.rnode_flasher_title);
- getSupportActionBar().setDisplayHomeAsUpEnabled(true);
- }
+ try {
+ setContentView(R.layout.activity_rnode_flasher);
+ if (getSupportActionBar() != null) {
+ getSupportActionBar().setTitle(R.string.rnode_flasher_title);
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+ }
- usbSpinner = findViewById(R.id.rnodeUsbSpinner);
- productSpinner = findViewById(R.id.rnodeProductSpinner);
- modelSpinner = findViewById(R.id.rnodeModelSpinner);
- progressBar = findViewById(R.id.rnodeProgress);
- statusView = findViewById(R.id.rnodeStatus);
- logView = findViewById(R.id.rnodeLog);
- flashButton = findViewById(R.id.rnodeFlash);
- downloadButton = findViewById(R.id.rnodeDownloadFirmware);
+ usbSpinner = findViewById(R.id.rnodeUsbSpinner);
+ productSpinner = findViewById(R.id.rnodeProductSpinner);
+ modelSpinner = findViewById(R.id.rnodeModelSpinner);
+ progressBar = findViewById(R.id.rnodeProgress);
+ statusView = findViewById(R.id.rnodeStatus);
+ logView = findViewById(R.id.rnodeLog);
+ flashButton = findViewById(R.id.rnodeFlash);
+ downloadButton = findViewById(R.id.rnodeDownloadFirmware);
- usbAdapter = new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, ports);
- usbSpinner.setAdapter(usbAdapter);
+ usbAdapter = new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, ports);
+ usbSpinner.setAdapter(usbAdapter);
- try {
- catalog = ProductCatalog.load(this);
- } catch (Exception e) {
- appendLog("Failed to load product catalog: " + e.getMessage());
- catalog = ProductCatalog.empty();
- }
- productAdapter =
- new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, catalog.products());
- productSpinner.setAdapter(productAdapter);
- modelAdapter =
- new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, new ArrayList<>());
- modelSpinner.setAdapter(modelAdapter);
-
- productSpinner.setOnItemSelectedListener(
- new AdapterView.OnItemSelectedListener() {
- @Override
- public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
- refreshModels();
- }
+ try {
+ catalog = ProductCatalog.load(this);
+ } catch (Exception e) {
+ appendLog("Failed to load product catalog: " + e.getMessage());
+ catalog = ProductCatalog.empty();
+ }
+ productAdapter =
+ new ArrayAdapter<>(
+ this, android.R.layout.simple_spinner_dropdown_item, catalog.products());
+ productSpinner.setAdapter(productAdapter);
+ modelAdapter =
+ new ArrayAdapter<>(
+ this, android.R.layout.simple_spinner_dropdown_item, new ArrayList<>());
+ modelSpinner.setAdapter(modelAdapter);
+
+ productSpinner.setOnItemSelectedListener(
+ new AdapterView.OnItemSelectedListener() {
+ @Override
+ public void onItemSelected(
+ AdapterView<?> parent, View view, int position, long id) {
+ refreshModels();
+ }
- @Override
- public void onNothingSelected(AdapterView<?> parent) {
+ @Override
+ public void onNothingSelected(AdapterView<?> parent) {
+ }
}
- }
- );
-
- findViewById(R.id.rnodeRefreshUsb).setOnClickListener(v -> refreshPorts());
- findViewById(R.id.rnodeRequestUsb).setOnClickListener(v -> requestUsbPermission());
- findViewById(R.id.rnodeRequestBluetooth).setOnClickListener(v -> requestBluetooth());
- findViewById(R.id.rnodeOpenAppSettings).setOnClickListener(v -> openAppSettings());
- downloadButton.setOnClickListener(v -> downloadFirmware());
- flashButton.setOnClickListener(v -> flashSelected());
-
- usbHub = new UsbSerialHub(this);
- usbHub.addListener(this);
- usbHub.start();
- refreshPorts();
- refreshModels();
- appendLog("Native RNode flasher ready.");
+ );
+
+ findViewById(R.id.rnodeRefreshUsb).setOnClickListener(v -> refreshPorts());
+ findViewById(R.id.rnodeRequestUsb).setOnClickListener(v -> requestUsbPermission());
+ findViewById(R.id.rnodeRequestBluetooth).setOnClickListener(v -> requestBluetooth());
+ findViewById(R.id.rnodeOpenAppSettings).setOnClickListener(v -> openAppSettings());
+ downloadButton.setOnClickListener(v -> downloadFirmware());
+ flashButton.setOnClickListener(v -> flashSelected());
+
+ usbHub = new UsbSerialHub(this);
+ usbHub.addListener(this);
+ usbHub.start();
+ refreshPorts();
+ refreshModels();
+ appendLog("Native RNode flasher ready.");
+ } catch (Exception e) {
+ String msg =
+ e.getMessage() != null && !e.getMessage().isEmpty()
+ ? e.getMessage()
+ : "Native RNode flasher failed to start";
+ Toast.makeText(this, msg, Toast.LENGTH_LONG).show();
+ finish();
+ }
}
@Override
@@ -197,7 +208,7 @@ public final class RNodeFlasherActivity extends AppCompatActivity implements Usb
"Bluetooth blocked. Enable it in app settings.",
Toast.LENGTH_LONG
).show();
- openAppSettings();
+ openBluetoothSettings();
return;
}
Toast.makeText(this, "Bluetooth denied", Toast.LENGTH_SHORT).show();
@@ -253,7 +264,7 @@ public final class RNodeFlasherActivity extends AppCompatActivity implements Usb
"Bluetooth blocked. Enable it in app settings.",
Toast.LENGTH_LONG
).show();
- openAppSettings();
+ openBluetoothSettings();
return;
}
getSharedPreferences(PREFS, MODE_PRIVATE).edit().putBoolean(PREF_BT_PROMPTED, true).apply();
@@ -262,18 +273,11 @@ public final class RNodeFlasherActivity extends AppCompatActivity implements Usb
}
private void openAppSettings() {
- try {
- Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
- intent.setData(Uri.fromParts("package", getPackageName(), null));
- startActivity(intent);
- } catch (ActivityNotFoundException e) {
- try {
- startActivity(new Intent(Settings.ACTION_MANAGE_APPLICATIONS_SETTINGS));
- } catch (ActivityNotFoundException ignored) {
- Toast.makeText(this, "App settings unavailable", Toast.LENGTH_SHORT).show();
- appendLog("App settings unavailable: " + e.getMessage());
- }
- }
+ AppSettingsLauncher.openAppDetails(this);
+ }
+
+ private void openBluetoothSettings() {
+ AppSettingsLauncher.openBluetoothSettings(this);
}
private void refreshPorts() {

diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml
index d60c2e82..08f83068 100644
--- a/android/app/src/main/res/values/themes.xml
+++ b/android/app/src/main/res/values/themes.xml
@@ -12,4 +12,18 @@
<item name="android:windowBackground">@color/meshchat_canvas</item>
<item name="android:colorBackground">@color/meshchat_canvas</item>
</style>
+
+ <!-- Native RNode flasher needs an ActionBar for up-navigation. -->
+ <style name="Theme.MeshChatX.Flasher" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
+ <item name="colorPrimary">@color/purple_500</item>
+ <item name="colorPrimaryVariant">@color/purple_700</item>
+ <item name="colorOnPrimary">@color/white</item>
+ <item name="colorSecondary">@color/teal_200</item>
+ <item name="colorSecondaryVariant">@color/teal_700</item>
+ <item name="colorOnSecondary">@color/black</item>
+ <item name="android:statusBarColor">@android:color/black</item>
+ <item name="android:navigationBarColor">@color/meshchat_canvas</item>
+ <item name="android:windowBackground">@color/meshchat_canvas</item>
+ <item name="android:colorBackground">@color/meshchat_canvas</item>
+ </style>
</resources>

diff --git a/meshchatx.rsm b/meshchatx.rsm
index 5e916eab..b3ece557 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ

diff --git a/meshchatx/android_codec2.py b/meshchatx/android_codec2.py
index dab4b0f6..c7d3fcd2 100644
--- a/meshchatx/android_codec2.py
+++ b/meshchatx/android_codec2.py
@@ -164,12 +164,36 @@ def ensure_codec2_native_library(*, force: bool = False) -> bool:
return False
+def _install_pycodec2_ctypes_fallback() -> tuple[bool, str | None]:
+ """Install a ctypes pycodec2 stand-in when the Cython extension cannot load.
+
+ Some Android vendor wheels ship an empty pycodec2.so (no PyInit). libcodec2.so
+ is still present. Expose the same Codec2 API LXST expects via ctypes.
+ """
+ try:
+ from meshchatx import pycodec2_ctypes
+
+ ok, err = pycodec2_ctypes.probe()
+ if not ok:
+ return False, err
+ # Drop a broken partial import so LXST picks up the stand-in.
+ sys.modules.pop("pycodec2", None)
+ sys.modules["pycodec2"] = pycodec2_ctypes
+ logger.warning(
+ "Using ctypes Codec2 fallback (native pycodec2 extension unavailable)"
+ )
+ return True, None
+ except Exception as exc:
+ return False, str(exc)
+
+
def probe_pycodec2() -> tuple[bool, str | None]:
"""Import pycodec2 after preload and report whether Codec2 works."""
if _is_chaquopy_android() and not ensure_codec2_native_library():
# Retry once in case native libs appeared after an early failed attempt.
if not ensure_codec2_native_library(force=True):
- return False, codec2_preload_error()
+ # Still try ctypes against whatever path we can find.
+ return _install_pycodec2_ctypes_fallback()
try:
import pycodec2
@@ -177,7 +201,13 @@ def probe_pycodec2() -> tuple[bool, str | None]:
_ = c2.samples_per_frame()
return True, None
except Exception as exc:
- return False, str(exc)
+ native_error = str(exc)
+ if _is_chaquopy_android():
+ ok, fallback_error = _install_pycodec2_ctypes_fallback()
+ if ok:
+ return True, None
+ return False, fallback_error or native_error
+ return False, native_error
def ensure_lxst_codec2_binding() -> bool:

diff --git a/meshchatx/pycodec2_ctypes.py b/meshchatx/pycodec2_ctypes.py
new file mode 100644
index 00000000..a49c00fd
--- /dev/null
+++ b/meshchatx/pycodec2_ctypes.py
@@ -0,0 +1,213 @@
+# SPDX-License-Identifier: 0BSD
+
+"""ctypes-backed pycodec2-compatible Codec2 for Android when the extension is broken.
+
+Chaquopy vendor wheels have shipped an empty pycodec2.so (no PyInit). libcodec2.so
+is valid and preloaded. This module exposes the subset of the pycodec2 API that
+LXST.Codecs.Codec2 and MeshChatX probes need.
+"""
+
+from __future__ import annotations
+
+import ctypes
+import logging
+import os
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+# codec2.h mode constants (bitrate arg maps through this table)
+_MODES = {
+ 700: 8, # CODEC2_MODE_700C
+ 1200: 5,
+ 1300: 4,
+ 1400: 3,
+ 1600: 2,
+ 2400: 1,
+ 3200: 0,
+}
+
+_lib: ctypes.CDLL | None = None
+_lib_error: str | None = None
+
+
+class _CODEC2(ctypes.Structure):
+ pass
+
+
+_CODEC2_p = ctypes.POINTER(_CODEC2)
+
+
+def _cdll_load(path_or_name: str) -> ctypes.CDLL:
+ mode = getattr(ctypes, "RTLD_GLOBAL", None)
+ if mode is None:
+ return ctypes.CDLL(path_or_name)
+ return ctypes.CDLL(path_or_name, mode=mode)
+
+
+def _candidate_lib_paths() -> list[str]:
+ paths: list[str] = []
+ seen: set[str] = set()
+
+ def add(value: str) -> None:
+ if value and value not in seen:
+ seen.add(value)
+ paths.append(value)
+
+ explicit = os.environ.get("MESHCHAT_LIBCODEC2_PATH", "") or ""
+ if explicit:
+ add(explicit)
+ native_dir = os.environ.get("MESHCHAT_NATIVE_LIB_DIR", "") or ""
+ if native_dir:
+ add(str(Path(native_dir) / "libcodec2.so"))
+ for entry in list(__import__("sys").path):
+ if not entry:
+ continue
+ root = Path(entry)
+ add(str(root / "pycodec2" / "libcodec2.so"))
+ add(str(root / "chaquopy" / "lib" / "libcodec2.so"))
+ add(str(root / "libcodec2.so"))
+ add("libcodec2.so")
+ return paths
+
+
+def _bind(lib: ctypes.CDLL) -> None:
+ lib.codec2_create.argtypes = [ctypes.c_int]
+ lib.codec2_create.restype = _CODEC2_p
+ lib.codec2_destroy.argtypes = [_CODEC2_p]
+ lib.codec2_destroy.restype = None
+ lib.codec2_encode.argtypes = [
+ _CODEC2_p,
+ ctypes.POINTER(ctypes.c_uint8),
+ ctypes.POINTER(ctypes.c_short),
+ ]
+ lib.codec2_encode.restype = None
+ lib.codec2_decode.argtypes = [
+ _CODEC2_p,
+ ctypes.POINTER(ctypes.c_short),
+ ctypes.POINTER(ctypes.c_uint8),
+ ]
+ lib.codec2_decode.restype = None
+ lib.codec2_samples_per_frame.argtypes = [_CODEC2_p]
+ lib.codec2_samples_per_frame.restype = ctypes.c_int
+ lib.codec2_bits_per_frame.argtypes = [_CODEC2_p]
+ lib.codec2_bits_per_frame.restype = ctypes.c_int
+ lib.codec2_bytes_per_frame.argtypes = [_CODEC2_p]
+ lib.codec2_bytes_per_frame.restype = ctypes.c_int
+
+
+def load_libcodec2(*, force: bool = False) -> ctypes.CDLL:
+ """Load and bind libcodec2 for ctypes Codec2 wrappers."""
+ global _lib, _lib_error
+ if _lib is not None and not force:
+ return _lib
+ last_error: str | None = None
+ for candidate in _candidate_lib_paths():
+ try:
+ if candidate != "libcodec2.so" and not Path(candidate).is_file():
+ continue
+ lib = _cdll_load(candidate)
+ _bind(lib)
+ # Touch create/destroy so we fail early on a stub library.
+ state = lib.codec2_create(_MODES[1600])
+ if not state:
+ raise OSError("codec2_create returned NULL")
+ lib.codec2_destroy(state)
+ _lib = lib
+ _lib_error = None
+ logger.info("pycodec2_ctypes loaded libcodec2 from %s", candidate)
+ return lib
+ except Exception as exc:
+ last_error = f"{candidate}: {exc}"
+ _lib = None
+ _lib_error = last_error or "libcodec2.so not found"
+ raise OSError(_lib_error)
+
+
+def libcodec2_load_error() -> str | None:
+ return _lib_error
+
+
+class Codec2:
+ """Minimal pycodec2.Codec2 stand-in used by LXST."""
+
+ def __init__(self, mode: int):
+ if mode not in _MODES:
+ raise ValueError(f"Unsupported Codec2 mode: {mode}")
+ lib = load_libcodec2()
+ self._lib = lib
+ self._state = lib.codec2_create(_MODES[mode])
+ if not self._state:
+ raise MemoryError("codec2_create failed")
+
+ def __del__(self):
+ state = getattr(self, "_state", None)
+ lib = getattr(self, "_lib", None)
+ if state and lib is not None:
+ try:
+ lib.codec2_destroy(state)
+ except Exception:
+ pass
+ self._state = None
+
+ def samples_per_frame(self) -> int:
+ return int(self._lib.codec2_samples_per_frame(self._state))
+
+ def bits_per_frame(self) -> int:
+ return int(self._lib.codec2_bits_per_frame(self._state))
+
+ def bytes_per_frame(self) -> int:
+ return int(self._lib.codec2_bytes_per_frame(self._state))
+
+ def encode(self, speech_in):
+ import numpy as np
+
+ samples = np.ascontiguousarray(speech_in, dtype=np.int16)
+ spf = self.samples_per_frame()
+ if samples.size == 0 or samples.size % spf != 0:
+ raise AssertionError(
+ "encode input length must be a multiple of samples_per_frame"
+ )
+ frames = samples.size // spf
+ bpf = self.bytes_per_frame()
+ out = (ctypes.c_uint8 * (frames * bpf))()
+ for index in range(frames):
+ frame_speech = (ctypes.c_short * spf).from_buffer(samples, index * spf * 2)
+ frame_bits = ctypes.cast(
+ ctypes.addressof(out) + index * bpf,
+ ctypes.POINTER(ctypes.c_uint8),
+ )
+ self._lib.codec2_encode(self._state, frame_bits, frame_speech)
+ return bytes(out)
+
+ def decode(self, frames: bytes):
+ import numpy as np
+
+ raw = bytes(frames)
+ bpf = self.bytes_per_frame()
+ if len(raw) < bpf:
+ raise AssertionError("decode input shorter than bytes_per_frame")
+ frame_count = len(raw) // bpf
+ spf = self.samples_per_frame()
+ speech = np.empty(frame_count * spf, dtype=np.int16)
+ for index in range(frame_count):
+ chunk = raw[index * bpf : (index + 1) * bpf]
+ bit_buf = (ctypes.c_uint8 * bpf).from_buffer_copy(chunk)
+ frame_speech = (ctypes.c_short * spf).from_buffer(speech, index * spf * 2)
+ self._lib.codec2_decode(
+ self._state,
+ frame_speech,
+ ctypes.cast(bit_buf, ctypes.POINTER(ctypes.c_uint8)),
+ )
+ return speech
+
+
+def probe() -> tuple[bool, str | None]:
+ """Return whether ctypes Codec2 can construct and report frame sizes."""
+ try:
+ c2 = Codec2(1600)
+ _ = c2.samples_per_frame()
+ _ = c2.bytes_per_frame()
+ return True, None
+ except Exception as exc:
+ return False, str(exc)

diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index b813e718..ea957dbd 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -726,6 +726,8 @@ export default {
wsDisconnectedDurationText: "",
wsReconnectedBanner: false,
wsDisconnectTickTimer: null,
+ wsDisconnectGraceTimer: null,
+ wsDisconnectBannerShown: false,
wsReconnectedHideTimer: null,
backendProcessExited: false,
backendExitCode: null,
@@ -1194,6 +1196,7 @@ export default {
this.wsDisconnected = false;
this.wsDisconnectedAt = null;
this.wsDisconnectedDurationText = "";
+ this.wsDisconnectBannerShown = false;
this.wsReconnectedBanner = false;
this.backendProcessExited = false;
this.backendExitCode = null;
@@ -1205,6 +1208,10 @@ export default {
clearInterval(this.wsDisconnectTickTimer);
this.wsDisconnectTickTimer = null;
}
+ if (this.wsDisconnectGraceTimer != null) {
+ clearTimeout(this.wsDisconnectGraceTimer);
+ this.wsDisconnectGraceTimer = null;
+ }
if (this.wsReconnectedHideTimer != null) {
clearTimeout(this.wsReconnectedHideTimer);
this.wsReconnectedHideTimer = null;
@@ -1216,7 +1223,8 @@ export default {
}
this.backendProcessExited = true;
this.backendExitCode = payload?.code ?? null;
- this.onWsShellDisconnected();
+ // Process exit is serious: show disconnect immediately.
+ this._showWsDisconnectedBannerNow();
},
async onRestartBackend() {
if (!window.electron?.restartBackend) {
@@ -1280,18 +1288,41 @@ export default {
ToastUtils.error(this.$t("app.view_backend_logs_failed"));
}
},
- onWsShellDisconnected() {
+ _showWsDisconnectedBannerNow() {
if (!this.shellRunning) {
return;
}
+ if (this.wsDisconnectGraceTimer != null) {
+ clearTimeout(this.wsDisconnectGraceTimer);
+ this.wsDisconnectGraceTimer = null;
+ }
this.wsDisconnected = true;
- this.wsDisconnectedAt = Date.now();
+ this.wsDisconnectBannerShown = true;
+ this.wsDisconnectedAt = this.wsDisconnectedAt || Date.now();
this._tickWsDisconnectedLabel();
if (this.wsDisconnectTickTimer != null) {
clearInterval(this.wsDisconnectTickTimer);
}
this.wsDisconnectTickTimer = setInterval(() => this._tickWsDisconnectedLabel(), 1000);
},
+ onWsShellDisconnected() {
+ if (!this.shellRunning) {
+ return;
+ }
+ // Ignore brief reconnect blips (startup, Android resume). Only scare
+ // the user if the socket stays down past the grace window.
+ if (this.wsDisconnected) {
+ return;
+ }
+ if (this.wsDisconnectGraceTimer != null) {
+ return;
+ }
+ this.wsDisconnectedAt = Date.now();
+ this.wsDisconnectGraceTimer = setTimeout(() => {
+ this.wsDisconnectGraceTimer = null;
+ this._showWsDisconnectedBannerNow();
+ }, 2500);
+ },
_tickWsDisconnectedLabel() {
if (!this.wsDisconnectedAt) {
this.wsDisconnectedDurationText = "";
@@ -1303,9 +1334,15 @@ export default {
if (!this.shellRunning) {
return;
}
+ const sawDisconnectBanner = this.wsDisconnectBannerShown;
+ if (this.wsDisconnectGraceTimer != null) {
+ clearTimeout(this.wsDisconnectGraceTimer);
+ this.wsDisconnectGraceTimer = null;
+ }
this.wsDisconnected = false;
this.wsDisconnectedAt = null;
this.wsDisconnectedDurationText = "";
+ this.wsDisconnectBannerShown = false;
this.backendProcessExited = false;
this.backendExitCode = null;
if (this.wsDisconnectTickTimer != null) {
@@ -1315,14 +1352,17 @@ export default {
const isReconnect = payload.isReconnect === true;
if (isReconnect) {
await this.resyncShellAfterWebsocketReconnect();
- this.wsReconnectedBanner = true;
- if (this.wsReconnectedHideTimer != null) {
- clearTimeout(this.wsReconnectedHideTimer);
+ // Only celebrate when the user actually saw a disconnect banner.
+ if (sawDisconnectBanner) {
+ this.wsReconnectedBanner = true;
+ if (this.wsReconnectedHideTimer != null) {
+ clearTimeout(this.wsReconnectedHideTimer);
+ }
+ this.wsReconnectedHideTimer = setTimeout(() => {
+ this.wsReconnectedBanner = false;
+ this.wsReconnectedHideTimer = null;
+ }, 4500);
}
- this.wsReconnectedHideTimer = setTimeout(() => {
- this.wsReconnectedBanner = false;
- this.wsReconnectedHideTimer = null;
- }, 4500);
}
},
async resyncShellAfterWebsocketReconnect() {

diff --git a/meshchatx/src/frontend/components/tools/RNodeFlasherPage.vue b/meshchatx/src/frontend/components/tools/RNodeFlasherPage.vue
index f3fb07c3..de9970c7 100644
--- a/meshchatx/src/frontend/components/tools/RNodeFlasherPage.vue
+++ b/meshchatx/src/frontend/components/tools/RNodeFlasherPage.vue
@@ -303,6 +303,8 @@ export default {
if (action === "open-native-flasher" || action === "request-usb") {
if (this.androidBridge.openRNodeFlasher()) {
ToastUtils.info(this.$t("tools.rnode_flasher.support.actions.opened_native"));
+ } else {
+ ToastUtils.warning(this.$t("tools.rnode_flasher.support.actions.open_native_failed"));
}
return;
}
@@ -324,7 +326,7 @@ export default {
if (this.androidBridge.openBluetoothSettings()) {
ToastUtils.info(this.$t("tools.rnode_flasher.support.actions.bluetooth_open_settings"));
} else {
- ToastUtils.warning(this.$t("tools.rnode_flasher.support.actions.bluetooth_unsupported"));
+ ToastUtils.warning(this.$t("tools.rnode_flasher.support.actions.bluetooth_settings_unavailable"));
}
return;
}

diff --git a/meshchatx/src/frontend/js/WebSocketConnection.js b/meshchatx/src/frontend/js/WebSocketConnection.js
index a3147093..ffb403bc 100644
--- a/meshchatx/src/frontend/js/WebSocketConnection.js
+++ b/meshchatx/src/frontend/js/WebSocketConnection.js
@@ -6,6 +6,9 @@ const PONG_TIMEOUT_MS = 12000;
const BASE_RECONNECT_MS = 1000;
const MAX_RECONNECT_MS = 60000;
const JITTER_MAX_MS = 400;
+// Foreground recovery: prefer a ping for longer before tearing down a still-OPEN socket.
+// Android WebViews often idle past one ping interval while backgrounded without a dead link.
+const FOREGROUND_FORCE_RECONNECT_IDLE_MS = 90000;
class WebSocketConnection {
constructor() {
@@ -176,9 +179,27 @@ class WebSocketConnection {
this._isForcedReconnect = false;
return;
}
- if (this._hadSuccessfulOpen) {
- this._pendingReconnectUi = true;
+ // Startup races (backend still binding) must not flash a disconnect banner.
+ if (!this._hadSuccessfulOpen) {
+ const delay = reconnectDelayWithJitterMs(
+ this._reconnectAttempt,
+ BASE_RECONNECT_MS,
+ MAX_RECONNECT_MS,
+ JITTER_MAX_MS
+ );
+ this._reconnectAttempt += 1;
+ if (this._reconnectTimeout != null) {
+ clearTimeout(this._reconnectTimeout);
+ }
+ this._reconnectTimeout = setTimeout(() => {
+ this._reconnectTimeout = null;
+ if (!this.destroyed) {
+ this.reconnect();
+ }
+ }, delay);
+ return;
}
+ this._pendingReconnectUi = true;
this.emit("disconnected");
const delay = reconnectDelayWithJitterMs(
this._reconnectAttempt,
@@ -235,7 +256,7 @@ class WebSocketConnection {
}
const idleTime = Date.now() - this._lastReceivedTime;
- if (idleTime > PING_INTERVAL_MS) {
+ if (idleTime > FOREGROUND_FORCE_RECONNECT_IDLE_MS) {
this.forceReconnect();
} else {
this._sendAppPing();
@@ -247,8 +268,9 @@ class WebSocketConnection {
return;
}
if (this.ws) {
- // Suppress the disconnect banner, but still tell the shell this is a
- // reconnect so CSRF/config/status resync after background-tab stalls.
+ // Suppress the disconnect banner. Still mark reconnect so CSRF/config
+ // resync after background-tab stalls, but App only celebrates if the
+ // disconnect banner was actually shown.
if (this._hadSuccessfulOpen) {
this._pendingReconnectUi = true;
}

diff --git a/meshchatx/src/frontend/js/rnode/AndroidBridge.js b/meshchatx/src/frontend/js/rnode/AndroidBridge.js
index 9769e683..154a30b8 100644
--- a/meshchatx/src/frontend/js/rnode/AndroidBridge.js
+++ b/meshchatx/src/frontend/js/rnode/AndroidBridge.js
@@ -105,7 +105,10 @@ export default class AndroidBridge {
return false;
}
return safeCall(() => {
- this.bridge.openRNodeFlasher();
+ const result = this.bridge.openRNodeFlasher();
+ if (typeof result === "string") {
+ return result === "ok" || result === "requested";
+ }
return true;
}, false);
}
@@ -115,7 +118,10 @@ export default class AndroidBridge {
return false;
}
return safeCall(() => {
- this.bridge.openBluetoothSettings();
+ const result = this.bridge.openBluetoothSettings();
+ if (typeof result === "string") {
+ return result === "ok" || result === "settings";
+ }
return true;
}, false);
}
@@ -125,7 +131,10 @@ export default class AndroidBridge {
return false;
}
return safeCall(() => {
- this.bridge.openUsbSettings();
+ const result = this.bridge.openUsbSettings();
+ if (typeof result === "string") {
+ return result === "ok" || result === "settings";
+ }
return true;
}, false);
}

diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index a70a4ac7..e79174af 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -2581,14 +2581,16 @@
"actions": {
"load_polyfill": "Polyfill laden",
"request_bluetooth": "Bluetooth erlauben",
- "open_settings": "Einstellungen öffnen",
- "polyfill_loading": "USB-Serial-Polyfill wird geladen...",
- "bluetooth_requested": "Bluetooth-Berechtigung angefordert.",
"request_usb": "USB erlauben",
"open_native": "Nativen Flasher öffnen",
+ "open_settings": "Einstellungen öffnen",
+ "polyfill_loading": "USB-Serial-Polyfill wird geladen...",
"opened_native": "Nativer RNode-Flasher geöffnet.",
+ "open_native_failed": "Nativer RNode-Flasher konnte nicht geöffnet werden.",
+ "bluetooth_requested": "Bluetooth-Berechtigung angefordert.",
"bluetooth_already_granted": "Bluetooth-Berechtigung ist bereits erteilt.",
"bluetooth_open_settings": "Öffnen Sie die App-Einstellungen und aktivieren Sie Bluetooth-Berechtigungen.",
+ "bluetooth_settings_unavailable": "Bluetooth- oder App-Einstellungen konnten auf diesem Gerät nicht geöffnet werden.",
"bluetooth_unsupported": "Anfrage der Bluetooth-Berechtigung ist nicht verfügbar.",
"bluetooth_granted": "Bluetooth-Berechtigung erteilt.",
"bluetooth_denied": "Bluetooth-Berechtigung verweigert.",

diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index 67caea91..8ebc5e48 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -2796,9 +2796,11 @@
"open_settings": "Open settings",
"polyfill_loading": "Loading USB serial polyfill...",
"opened_native": "Opened the native RNode flasher.",
+ "open_native_failed": "Could not open the native RNode flasher.",
"bluetooth_requested": "Bluetooth permission requested.",
"bluetooth_already_granted": "Bluetooth permission is already granted.",
"bluetooth_open_settings": "Open app settings and enable Bluetooth permissions.",
+ "bluetooth_settings_unavailable": "Could not open Bluetooth or app settings on this device.",
"bluetooth_unsupported": "Bluetooth permission request is unavailable.",
"bluetooth_granted": "Bluetooth permission granted.",
"bluetooth_denied": "Bluetooth permission denied.",

diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index 7038b8b3..4a315be9 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -2787,14 +2787,16 @@
"actions": {
"load_polyfill": "Cargar polyfill",
"request_bluetooth": "Permitir Bluetooth",
- "open_settings": "Abrir configuración",
- "polyfill_loading": "Cargando polyfill de USB serial...",
- "bluetooth_requested": "Permiso de Bluetooth solicitado.",
"request_usb": "Permitir USB",
"open_native": "Abrir flasher nativo",
+ "open_settings": "Abrir configuración",
+ "polyfill_loading": "Cargando polyfill de USB serial...",
"opened_native": "Se abrió el flasher nativo de RNode.",
+ "open_native_failed": "No se pudo abrir el flasher nativo de RNode.",
+ "bluetooth_requested": "Permiso de Bluetooth solicitado.",
"bluetooth_already_granted": "El permiso de Bluetooth ya está concedido.",
"bluetooth_open_settings": "Abre la configuración de la app y activa los permisos de Bluetooth.",
+ "bluetooth_settings_unavailable": "No se pudo abrir la configuración de Bluetooth o de la app en este dispositivo.",
"bluetooth_unsupported": "La solicitud de permiso de Bluetooth no está disponible.",
"bluetooth_granted": "Permiso de Bluetooth concedido.",
"bluetooth_denied": "Permiso de Bluetooth denegado.",

diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index 8f6c06d8..a99b2aaf 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -2787,14 +2787,16 @@
"actions": {
"load_polyfill": "Lataa polyfill",
"request_bluetooth": "Salli Bluetooth",
- "open_settings": "Avaa asetukset",
- "polyfill_loading": "Ladataan USB-sarjaportin polyfilliä...",
- "bluetooth_requested": "Bluetooth-lupaa pyydetty.",
"request_usb": "Salli USB",
"open_native": "Avaa natiivi flasher",
+ "open_settings": "Avaa asetukset",
+ "polyfill_loading": "Ladataan USB-sarjaportin polyfilliä...",
"opened_native": "Natiivi RNode-flasher avattu.",
+ "open_native_failed": "Natiivia RNode-flasheria ei voitu avata.",
+ "bluetooth_requested": "Bluetooth-lupaa pyydetty.",
"bluetooth_already_granted": "Bluetooth-lupa on jo myönnetty.",
"bluetooth_open_settings": "Avaa sovelluksen asetukset ja ota Bluetooth-luvat käyttöön.",
+ "bluetooth_settings_unavailable": "Bluetooth- tai sovellusasetuksia ei voitu avata tällä laitteella.",
"bluetooth_unsupported": "Bluetooth-lupapyyntö ei ole käytettävissä.",
"bluetooth_granted": "Bluetooth-lupa myönnetty.",
"bluetooth_denied": "Bluetooth-lupa evätty.",

diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index ffd85d33..0e2a67bc 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -2787,14 +2787,16 @@
"actions": {
"load_polyfill": "Charger le polyfill",
"request_bluetooth": "Autoriser le Bluetooth",
- "open_settings": "Ouvrir les paramètres",
- "polyfill_loading": "Chargement du polyfill USB série...",
- "bluetooth_requested": "Permission Bluetooth demandée.",
"request_usb": "Autoriser l'USB",
"open_native": "Ouvrir le flasher natif",
+ "open_settings": "Ouvrir les paramètres",
+ "polyfill_loading": "Chargement du polyfill USB série...",
"opened_native": "Flasher RNode natif ouvert.",
+ "open_native_failed": "Impossible d'ouvrir le flasher RNode natif.",
+ "bluetooth_requested": "Permission Bluetooth demandée.",
"bluetooth_already_granted": "La permission Bluetooth est déjà accordée.",
"bluetooth_open_settings": "Ouvrez les paramètres de l'application et activez les permissions Bluetooth.",
+ "bluetooth_settings_unavailable": "Impossible d'ouvrir les paramètres Bluetooth ou de l'application sur cet appareil.",
"bluetooth_unsupported": "La demande de permission Bluetooth est indisponible.",
"bluetooth_granted": "Permission Bluetooth accordée.",
"bluetooth_denied": "Permission Bluetooth refusée.",

diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index c26e73c1..67384a78 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -2839,14 +2839,16 @@
"actions": {
"load_polyfill": "Carica polyfill",
"request_bluetooth": "Consenti Bluetooth",
- "open_settings": "Apri impostazioni",
- "polyfill_loading": "Caricamento polyfill USB seriale...",
- "bluetooth_requested": "Autorizzazione Bluetooth richiesta.",
"request_usb": "Consenti USB",
"open_native": "Apri flasher nativo",
+ "open_settings": "Apri impostazioni",
+ "polyfill_loading": "Caricamento polyfill USB seriale...",
"opened_native": "Flasher nativo RNode aperto.",
+ "open_native_failed": "Impossibile aprire il flasher nativo RNode.",
+ "bluetooth_requested": "Autorizzazione Bluetooth richiesta.",
"bluetooth_already_granted": "L'autorizzazione Bluetooth è già concessa.",
"bluetooth_open_settings": "Apri le impostazioni dell'app e abilita i permessi Bluetooth.",
+ "bluetooth_settings_unavailable": "Impossibile aprire le impostazioni Bluetooth o dell'app su questo dispositivo.",
"bluetooth_unsupported": "La richiesta di autorizzazione Bluetooth non è disponibile.",
"bluetooth_granted": "Autorizzazione Bluetooth concessa.",
"bluetooth_denied": "Autorizzazione Bluetooth negata.",

diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index 989c5be7..fcf9e29d 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -2787,14 +2787,16 @@
"actions": {
"load_polyfill": "Polyfill laden",
"request_bluetooth": "Bluetooth toestaan",
- "open_settings": "Instellingen openen",
- "polyfill_loading": "USB-serieel polyfill laden...",
- "bluetooth_requested": "Bluetooth-toestemming aangevraagd.",
"request_usb": "USB toestaan",
"open_native": "Native flasher openen",
+ "open_settings": "Instellingen openen",
+ "polyfill_loading": "USB-serieel polyfill laden...",
"opened_native": "Native RNode-flasher geopend.",
+ "open_native_failed": "Kon de native RNode-flasher niet openen.",
+ "bluetooth_requested": "Bluetooth-toestemming aangevraagd.",
"bluetooth_already_granted": "Bluetooth-toestemming is al verleend.",
"bluetooth_open_settings": "Open de app-instellingen en schakel Bluetooth-machtigingen in.",
+ "bluetooth_settings_unavailable": "Kon Bluetooth- of app-instellingen op dit apparaat niet openen.",
"bluetooth_unsupported": "Bluetooth-toestemmingsverzoek is niet beschikbaar.",
"bluetooth_granted": "Bluetooth-toestemming verleend.",
"bluetooth_denied": "Bluetooth-toestemming geweigerd.",

diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index d97b8691..bd7b74e1 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -2581,14 +2581,16 @@
"actions": {
"load_polyfill": "Загрузить полифилл",
"request_bluetooth": "Разрешить Bluetooth",
- "open_settings": "Открыть настройки",
- "polyfill_loading": "Загрузка полифилла USB-последовательного порта...",
- "bluetooth_requested": "Запрошено разрешение Bluetooth.",
"request_usb": "Разрешить USB",
"open_native": "Открыть встроенный прошивальщик",
+ "open_settings": "Открыть настройки",
+ "polyfill_loading": "Загрузка полифилла USB-последовательного порта...",
"opened_native": "Встроенный прошивальщик RNode открыт.",
+ "open_native_failed": "Не удалось открыть встроенный прошивальщик RNode.",
+ "bluetooth_requested": "Запрошено разрешение Bluetooth.",
"bluetooth_already_granted": "Разрешение Bluetooth уже выдано.",
"bluetooth_open_settings": "Откройте настройки приложения и включите разрешения Bluetooth.",
+ "bluetooth_settings_unavailable": "Не удалось открыть настройки Bluetooth или приложения на этом устройстве.",
"bluetooth_unsupported": "Запрос разрешения Bluetooth недоступен.",
"bluetooth_granted": "Разрешение Bluetooth выдано.",
"bluetooth_denied": "В разрешении Bluetooth отказано.",

diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index 56face58..c28f5397 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -2787,14 +2787,16 @@
"actions": {
"load_polyfill": "加载 polyfill",
"request_bluetooth": "允许蓝牙",
- "open_settings": "打开设置",
- "polyfill_loading": "正在加载 USB 串口 polyfill...",
- "bluetooth_requested": "已请求蓝牙权限。",
"request_usb": "允许 USB",
"open_native": "打开原生刷写工具",
+ "open_settings": "打开设置",
+ "polyfill_loading": "正在加载 USB 串口 polyfill...",
"opened_native": "已打开原生 RNode 刷写工具。",
+ "open_native_failed": "无法打开原生 RNode 刷写工具。",
+ "bluetooth_requested": "已请求蓝牙权限。",
"bluetooth_already_granted": "已授予蓝牙权限。",
"bluetooth_open_settings": "打开应用设置并启用蓝牙权限。",
+ "bluetooth_settings_unavailable": "无法在此设备上打开蓝牙或应用设置。",
"bluetooth_unsupported": "无法请求蓝牙权限。",
"bluetooth_granted": "已授予蓝牙权限。",
"bluetooth_denied": "蓝牙权限被拒绝。",

diff --git a/tests/backend/test_android_codec2.py b/tests/backend/test_android_codec2.py
index 976c46d2..7dff06e5 100644
--- a/tests/backend/test_android_codec2.py
+++ b/tests/backend/test_android_codec2.py
@@ -139,6 +139,37 @@ def test_ensure_lxst_codec2_binding_reloads_when_codec2_none():
assert reload_mock.called
+def test_probe_falls_back_to_ctypes_on_android_when_pycodec2_broken():
+ android_codec2.reset_codec2_preload_state_for_tests()
+
+ with (
+ patch.object(android_codec2, "_is_chaquopy_android", return_value=True),
+ patch.object(android_codec2, "ensure_codec2_native_library", return_value=True),
+ patch.dict("sys.modules", {"pycodec2": None}),
+ ):
+ import builtins
+
+ real_import = builtins.__import__
+
+ def fake_import(name, *args, **kwargs):
+ if name == "pycodec2":
+ raise ImportError("empty pycodec2.so")
+ return real_import(name, *args, **kwargs)
+
+ with (
+ patch("builtins.__import__", side_effect=fake_import),
+ patch.object(
+ android_codec2,
+ "_install_pycodec2_ctypes_fallback",
+ return_value=(True, None),
+ ) as install,
+ ):
+ ok, err = android_codec2.probe_pycodec2()
+ assert ok is True
+ assert err is None
+ install.assert_called_once()
+
+
def test_vendor_wheels_bundle_libcodec2_for_all_abis():
import zipfile
@@ -154,6 +185,12 @@ def test_vendor_wheels_bundle_libcodec2_for_all_abis():
with zipfile.ZipFile(wheels[-1]) as zin:
assert "pycodec2/libcodec2.so" in zin.namelist()
assert "pycodec2/pycodec2.so" in zin.namelist()
+ # Empty stub extensions (a few KB, no PyInit) cannot provide Codec2.
+ # ctypes fallback covers those builds until wheels are rebuilt.
+ stub_size = zin.getinfo("pycodec2/pycodec2.so").file_size
+ assert stub_size > 0
+ lib_size = zin.getinfo("pycodec2/libcodec2.so").file_size
+ assert lib_size > 100_000
lib_wheels = sorted(vendor.glob(f"chaquopy_libcodec2-*-android_24_{abi}.whl"))
if not lib_wheels:
pytest.skip(f"missing chaquopy_libcodec2 for {abi}")
@@ -229,3 +266,50 @@ def test_repack_script_bundles_libcodec2(tmp_path):
size_field = line.rsplit(",", 1)[-1]
assert size_field
int(size_field)
+
+
+def test_pycodec2_ctypes_roundtrip_with_system_or_jni_lib():
+ """Ctypes Codec2 must encode/decode when libcodec2 is available."""
+ import ctypes.util
+
+ from meshchatx import pycodec2_ctypes
+
+ repo = Path(__file__).resolve().parents[2]
+ jni_lib = (
+ repo
+ / "android"
+ / "app"
+ / "src"
+ / "main"
+ / "jniLibs"
+ / "arm64-v8a"
+ / "libcodec2.so"
+ )
+ system_lib = ctypes.util.find_library("codec2")
+ if system_lib:
+ lib_path = system_lib
+ elif jni_lib.is_file():
+ # Android aarch64 .so will not load on x86_64 hosts.
+ pytest.skip("host cannot load Android arm64 libcodec2.so")
+ else:
+ pytest.skip("libcodec2 not available on host")
+
+ pycodec2_ctypes._lib = None
+ pycodec2_ctypes._lib_error = None
+ with patch.dict("os.environ", {"MESHCHAT_LIBCODEC2_PATH": str(lib_path)}):
+ ok, err = pycodec2_ctypes.probe()
+ if not ok:
+ pytest.skip(f"ctypes Codec2 probe failed: {err}")
+ import numpy as np
+
+ c2 = pycodec2_ctypes.Codec2(1600)
+ spf = c2.samples_per_frame()
+ samples = np.zeros(spf, dtype=np.int16)
+ encoded = c2.encode(samples)
+ assert isinstance(encoded, (bytes, bytearray))
+ assert len(encoded) == c2.bytes_per_frame()
+ decoded = c2.decode(encoded)
+ assert decoded.dtype == np.int16
+ assert decoded.size == spf
+ pycodec2_ctypes._lib = None
+ pycodec2_ctypes._lib_error = None

diff --git a/tests/frontend/AppWsReconnectResync.test.js b/tests/frontend/AppWsReconnectResync.test.js
index b12b463e..12bda59f 100644
--- a/tests/frontend/AppWsReconnectResync.test.js
+++ b/tests/frontend/AppWsReconnectResync.test.js
@@ -20,6 +20,7 @@ describe("App websocket reconnect shell resync", () => {
afterEach(() => {
vi.restoreAllMocks();
+ vi.useRealTimers();
});
function makeShellCtx(overrides = {}) {
@@ -28,6 +29,8 @@ describe("App websocket reconnect shell resync", () => {
wsDisconnected: true,
wsDisconnectedAt: Date.now() - 5000,
wsDisconnectedDurationText: "5s",
+ wsDisconnectBannerShown: true,
+ wsDisconnectGraceTimer: null,
backendProcessExited: false,
backendExitCode: null,
wsDisconnectTickTimer: null,
@@ -42,6 +45,9 @@ describe("App websocket reconnect shell resync", () => {
updatePropagationNodeStatus: vi.fn(async () => {}),
resyncShellAfterWebsocketReconnect: App.methods.resyncShellAfterWebsocketReconnect,
onWsShellConnected: App.methods.onWsShellConnected,
+ onWsShellDisconnected: App.methods.onWsShellDisconnected,
+ _showWsDisconnectedBannerNow: App.methods._showWsDisconnectedBannerNow,
+ _tickWsDisconnectedLabel: App.methods._tickWsDisconnectedLabel,
...overrides,
};
}
@@ -64,9 +70,52 @@ describe("App websocket reconnect shell resync", () => {
emitSpy.mockRestore();
});
+ it("resyncs silently after a brief reconnect without celebrating", async () => {
+ const emitSpy = vi.spyOn(GlobalEmitter, "emit");
+ const ctx = makeShellCtx({
+ wsDisconnected: false,
+ wsDisconnectedAt: null,
+ wsDisconnectBannerShown: false,
+ });
+
+ await App.methods.onWsShellConnected.call(ctx, { isReconnect: true });
+
+ expect(fetchCsrfToken).toHaveBeenCalledTimes(1);
+ expect(ctx.wsReconnectedBanner).toBe(false);
+ expect(emitSpy).toHaveBeenCalledWith("websocket-reconnected");
+
+ emitSpy.mockRestore();
+ });
+
+ it("does not show disconnect banner during the grace window", async () => {
+ vi.useFakeTimers();
+ const ctx = makeShellCtx({
+ wsDisconnected: false,
+ wsDisconnectedAt: null,
+ wsDisconnectBannerShown: false,
+ wsDisconnectGraceTimer: null,
+ wsDisconnectTickTimer: null,
+ });
+
+ App.methods.onWsShellDisconnected.call(ctx);
+ expect(ctx.wsDisconnected).toBe(false);
+ expect(ctx.wsDisconnectGraceTimer).not.toBeNull();
+
+ await vi.advanceTimersByTimeAsync(2499);
+ expect(ctx.wsDisconnected).toBe(false);
+
+ await vi.advanceTimersByTimeAsync(2);
+ expect(ctx.wsDisconnected).toBe(true);
+ expect(ctx.wsDisconnectBannerShown).toBe(true);
+ });
+
it("does not resync shell on the first websocket connect", async () => {
const emitSpy = vi.spyOn(GlobalEmitter, "emit");
- const ctx = makeShellCtx({ wsDisconnected: false, wsDisconnectedAt: null });
+ const ctx = makeShellCtx({
+ wsDisconnected: false,
+ wsDisconnectedAt: null,
+ wsDisconnectBannerShown: false,
+ });
await App.methods.onWsShellConnected.call(ctx, { isReconnect: false });

diff --git a/tests/frontend/RNodeAndroidBridge.test.js b/tests/frontend/RNodeAndroidBridge.test.js
index edfd76c4..99188247 100644
--- a/tests/frontend/RNodeAndroidBridge.test.js
+++ b/tests/frontend/RNodeAndroidBridge.test.js
@@ -42,23 +42,42 @@ describe("AndroidBridge", () => {
});
it("hasNativeRNodeFlasher delegates to the bridge", () => {
- const bridge = { hasNativeRNodeFlasher: vi.fn().mockReturnValue(true), openRNodeFlasher: vi.fn() };
+ const bridge = {
+ hasNativeRNodeFlasher: vi.fn().mockReturnValue(true),
+ openRNodeFlasher: vi.fn().mockReturnValue("ok"),
+ };
const ab = new AndroidBridge(bridge, {});
expect(ab.hasNativeRNodeFlasher()).toBe(true);
expect(ab.openRNodeFlasher()).toBe(true);
expect(bridge.openRNodeFlasher).toHaveBeenCalled();
});
+ it("openRNodeFlasher treats error status as failure", () => {
+ const bridge = { openRNodeFlasher: vi.fn().mockReturnValue("error:boom") };
+ const ab = new AndroidBridge(bridge, {});
+ expect(ab.openRNodeFlasher()).toBe(false);
+ });
+
it("settings helpers return true when bridge accepts the call", () => {
const bridge = {
- openBluetoothSettings: vi.fn(),
- openUsbSettings: vi.fn(),
+ openBluetoothSettings: vi.fn().mockReturnValue("ok"),
+ openUsbSettings: vi.fn().mockReturnValue("ok"),
};
const ab = new AndroidBridge(bridge, {});
expect(ab.openBluetoothSettings()).toBe(true);
expect(ab.openUsbSettings()).toBe(true);
});
+ it("settings helpers treat unavailable status as failure", () => {
+ const bridge = {
+ openBluetoothSettings: vi.fn().mockReturnValue("unavailable"),
+ openUsbSettings: vi.fn().mockReturnValue("unavailable"),
+ };
+ const ab = new AndroidBridge(bridge, {});
+ expect(ab.openBluetoothSettings()).toBe(false);
+ expect(ab.openUsbSettings()).toBe(false);
+ });
+
it("settings helpers swallow exceptions and return false", () => {
const bridge = {
openBluetoothSettings: () => {

diff --git a/tests/frontend/WebSocketConnection.test.js b/tests/frontend/WebSocketConnection.test.js
index 6d619a38..b0026603 100644
--- a/tests/frontend/WebSocketConnection.test.js
+++ b/tests/frontend/WebSocketConnection.test.js
@@ -177,7 +177,7 @@ describe("WebSocketConnection module", () => {
expect(WebSocketConnection.ws).toBe(firstWs);
// 2. If idleTime is large, it should force a reconnect
- WebSocketConnection._lastReceivedTime = Date.now() - 60000;
+ WebSocketConnection._lastReceivedTime = Date.now() - 120000;
WebSocketConnection.handleForegroundOrNetworkChange();
// Wait for the new WebSocket to be created and opened
@@ -225,7 +225,7 @@ describe("WebSocketConnection module", () => {
await vi.waitUntil(() => WebSocketConnection.ws?.readyState === MockWS.OPEN);
const firstWs = WebSocketConnection.ws;
- WebSocketConnection._lastReceivedTime = Date.now() - 60000;
+ WebSocketConnection._lastReceivedTime = Date.now() - 120000;
WebSocketConnection.handleForegroundOrNetworkChange();
const connectingWs = WebSocketConnection.ws;
@@ -300,7 +300,7 @@ describe("WebSocketConnection module", () => {
await vi.waitUntil(() => WebSocketConnection.ws?.readyState === MockWS.OPEN);
const firstWs = WebSocketConnection.ws;
- WebSocketConnection._lastReceivedTime = Date.now() - 60000;
+ WebSocketConnection._lastReceivedTime = Date.now() - 120000;
WebSocketConnection.handleForegroundOrNetworkChange();
await vi.waitUntil(() => WebSocketConnection.ws && WebSocketConnection.ws !== firstWs);
@@ -315,6 +315,57 @@ describe("WebSocketConnection module", () => {
WebSocketConnection.destroy();
});
+ it("does not emit disconnected before the first successful open", async () => {
+ global.WebSocket = class FailingWS {
+ static CONNECTING = 0;
+ static OPEN = 1;
+ static CLOSING = 2;
+ static CLOSED = 3;
+
+ constructor(url) {
+ this.url = url;
+ this.readyState = FailingWS.CONNECTING;
+ this._listeners = { open: [], close: [], error: [], message: [] };
+ queueMicrotask(() => {
+ this.readyState = FailingWS.CLOSED;
+ this._listeners.close.forEach((fn) => fn({ code: 1006, reason: "startup" }));
+ });
+ }
+
+ addEventListener(type, fn) {
+ this._listeners[type]?.push(fn);
+ }
+
+ send() {}
+
+ close() {
+ if (this.readyState === FailingWS.CLOSED) {
+ return;
+ }
+ this.readyState = FailingWS.CLOSED;
+ queueMicrotask(() => {
+ this._listeners.close.forEach((fn) => fn({}));
+ });
+ }
+ };
+
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+
+ const { default: WebSocketConnection } = await import("../../meshchatx/src/frontend/js/WebSocketConnection.js");
+
+ const connected = vi.fn();
+ const disconnected = vi.fn();
+ WebSocketConnection.on("connected", connected);
+ WebSocketConnection.on("disconnected", disconnected);
+
+ WebSocketConnection.connect();
+ await vi.advanceTimersByTimeAsync(50);
+ expect(disconnected).not.toHaveBeenCalled();
+ expect(connected).not.toHaveBeenCalled();
+
+ WebSocketConnection.destroy();
+ });
+
it("marks forced reconnect as isReconnect after a prior successful open (background-tab stall)", async () => {
const MockWS = makeWsImpl();
global.WebSocket = MockWS;
@@ -331,7 +382,7 @@ describe("WebSocketConnection module", () => {
const firstWs = WebSocketConnection.ws;
// Simulate a zombie OPEN socket after the tab slept: readyState still
// OPEN, but no frames for longer than the ping interval.
- WebSocketConnection._lastReceivedTime = Date.now() - 60000;
+ WebSocketConnection._lastReceivedTime = Date.now() - 120000;
WebSocketConnection.forceReconnect();
await vi.waitUntil(() => WebSocketConnection.ws && WebSocketConnection.ws !== firstWs);
@@ -411,7 +462,7 @@ describe("WebSocketConnection module", () => {
await vi.waitUntil(() => WebSocketConnection.ws?.readyState === MockWS.OPEN);
const firstWs = WebSocketConnection.ws;
- WebSocketConnection._lastReceivedTime = Date.now() - 60000;
+ WebSocketConnection._lastReceivedTime = Date.now() - 120000;
// still hidden - must not trigger a reconnect
global.window.dispatchEvent(new Event("visibilitychange"));
@@ -448,7 +499,7 @@ describe("WebSocketConnection module", () => {
expect(sendSpy).toHaveBeenCalled();
expect(WebSocketConnection.ws).toBe(firstWs);
- WebSocketConnection._lastReceivedTime = Date.now() - 60000;
+ WebSocketConnection._lastReceivedTime = Date.now() - 120000;
global.window.dispatchEvent(new Event("online"));
await vi.waitUntil(() => WebSocketConnection.ws && WebSocketConnection.ws !== firstWs);


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────